fix(router): propagate session_id/user_id to LiteLLM agent-completion traces#325
Conversation
… traces LiteLLM creates agent-completion as a separate trace that does not inherit OpenTelemetry context from propagate_attributes(). Propagate session_id and trace_user_id via both the request metadata dict (which LiteLLM's Langfuse callback reads) and langfuse_* headers for belt-and-suspenders coverage. Closes #303
There was a problem hiding this comment.
Sorry @sheepdestroyer, you have reached your weekly rate limit of 500000 diff characters.
Please try again later or upgrade to continue using Sourcery
|
Warning Review limit reached
Next review available in: 55 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. 📝 WalkthroughWalkthroughThe LiteLLM proxy path now copies existing request metadata before modifying it, sets the agent-completion trace name, and conditionally propagates session and user identifiers for retry and fallback requests. ChangesTrace metadata propagation
Estimated code review effort: 2 (Simple) | ~10 minutes Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Code Review
This pull request introduces Langfuse session and user ID tracking to proxy requests in router/main.py by adding them to both the outgoing HTTP headers and the request metadata. The review feedback highlights two key issues: first, the custom HTTP headers use underscores, which are often dropped by reverse proxies, and should be renamed to standard hyphenated headers (x-langfuse-session-id and x-langfuse-user-id). Second, mutating the nested metadata dictionary on a shallow copy of the request body can cause unintended side effects during fallback retries, so the metadata dictionary should be copied before modification.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| if _trace_session_id: | ||
| headers["langfuse_session_id"] = _trace_session_id | ||
| if _trace_user_id: | ||
| headers["langfuse_trace_user_id"] = _trace_user_id |
There was a problem hiding this comment.
Using underscores in HTTP header names (e.g., langfuse_session_id and langfuse_trace_user_id) is problematic because most reverse proxies and web servers (such as Nginx, HAProxy, and Apache) silently drop headers containing underscores by default to prevent header spoofing and CGI variable clashes.
Additionally, LiteLLM's Langfuse integration expects standard headers:
x-langfuse-session-idx-langfuse-user-id
To ensure these headers are not dropped by intermediate proxies and are correctly recognized by LiteLLM, they should be renamed to use hyphens and follow the standard naming convention.
| if _trace_session_id: | |
| headers["langfuse_session_id"] = _trace_session_id | |
| if _trace_user_id: | |
| headers["langfuse_trace_user_id"] = _trace_user_id | |
| if _trace_session_id: | |
| headers['x-langfuse-session-id'] = _trace_session_id | |
| if _trace_user_id: | |
| headers['x-langfuse-user-id'] = _trace_user_id |
| ): | ||
| body_to_send["metadata"] = {} | ||
| body_to_send["metadata"]["trace_name"] = "agent-completion" |
There was a problem hiding this comment.
Since body_to_send is created as a shallow copy of body (body_to_send = body.copy()), mutating its nested "metadata" dictionary directly will also mutate the original body dictionary. If the request fails and falls back to another model (which calls execute_proxy again with the same body object), the subsequent calls will receive the mutated metadata.
To prevent unintended side effects and ensure clean fallback behavior, copy the "metadata" dictionary if it already exists.
| ): | |
| body_to_send["metadata"] = {} | |
| body_to_send["metadata"]["trace_name"] = "agent-completion" | |
| ): | |
| body_to_send['metadata'] = {} | |
| else: | |
| body_to_send['metadata'] = body_to_send['metadata'].copy() | |
| body_to_send['metadata']['trace_name'] = 'agent-completion' |
E2E VerifiedRebuilt dev pod with fix and tested: Before fix (current prod):
After fix (dev pod,
Both traces now carry the session/user context. Ready for merge. |
- Remove langfuse_session_id/langfuse_trace_user_id headers (underscores cause reverse proxy drops; metadata dict propagation is sufficient) - Deep-copy metadata dict before mutation to prevent corrupting original body dict during fallback retries (shallow copy shares the dict object) Gemini CA findings addressed.
…ts, explicit return None - Remove duplicate _redis_client/_redis_last_init_attempt globals (lines shadowed) - Remove redundant import uuid from native_agy_stream_generator and agy_stream_generator - Change bare return None to return in _end_parent_obs and _close_prop_ctx Pre-existing issues caught during PR #312 review, applicable across codebase.
sheepdestroyer
left a comment
There was a problem hiding this comment.
Hermes Agent Code Review — PR #325
Branch: fix/langfuse-agent-completion-session
Commit: 2cc6791
Scope: 1 file changed, 12 insertions, 13 deletions — router/main.py only
1. Files Read & Functions Analyzed
Full file read: router/main.py (~4535 lines), read in multiple chunks covering all changed regions:
- Lines 1–80: Module globals (duplicate
_redis_clientetc.) - Lines 310–397: Helper functions
_end_parent_obs,_end_child_span,_close_prop_ctx,_make_prop_ctx - Lines 2000–2050:
_trace_session_id/_trace_user_idextraction, Langfuse parent trace setup - Lines 2215–2435: Native AGY stream generator, fallback AGY stream generator (import uuid removals)
- Lines 2520–2968:
execute_proxy()— the primary target function, read in full (~450 lines) - Final 50 lines of
chat_completionsouter finally block
Functions analyzed by name:
execute_proxy()(line 2520) — metadata deep-copy, session_id/user_id propagation_make_prop_ctx()(line 383) — DRY-consolidated context manager factory_end_parent_obs()(line 325) —return None→returncleanup_close_prop_ctx()(line 368) —return None→returncleanupnative_agy_stream_generator()(line 2226) — redundantimport uuidremoval, addedimport timeagy_stream_generator()(line 2387) — redundantimport uuidremoval
2. Gemini CA Findings Verification
✅ Gemini CA #1 (3572488836) — Underscored headers: FIXED
The PR removes the approach of passing session/user info via underscored HTTP headers (e.g. x-langfuse-session-id). Instead, the router now propagates session_id and trace_user_id via the metadata dict of the downstream request body:
router/main.py:2602-2614
if "metadata" not in body_to_send or not isinstance(body_to_send["metadata"], dict):
body_to_send["metadata"] = {}
else:
body_to_send["metadata"] = dict(body_to_send["metadata"])
body_to_send["metadata"]["trace_name"] = "agent-completion"
if _trace_session_id:
body_to_send["metadata"]["session_id"] = _trace_session_id
if _trace_user_id:
body_to_send["metadata"]["trace_user_id"] = _trace_user_id
Global grep confirmed zero residual underscores: rg "langfuse_session_id|langfuse_trace_user_id" --type py returns no matches.
✅ Gemini CA #2 (3572488857) — Shallow copy mutates original body: FIXED
The critical fix is body_to_send["metadata"] = dict(body_to_send["metadata"]) at line 2609. This ensures the metadata dict is deep-copied before mutation, so the original body dict (from chat_completions outer scope) retains its pristine metadata through fallback retries.
Trace analysis: execute_proxy is called multiple times during fallback:
- Line 2565:
body_to_send = body.copy()— shallow copy, shares the metadata sub-dict - Lines 2857, 2897, 2945, 2962: fallback calls to
execute_proxy(original_target_model)re-execute line 2565 with the originalbody - With the fix, each call now gets a fresh deep-copied metadata dict, preventing cross-retry corruption
3. Code Quality Analysis
✅ Variable Scope — Correct
_trace_session_id and _trace_user_id are extracted at lines 2009–2021 in the outer chat_completions function and are lexically accessible inside the nested execute_proxy() closure (line 2520). Both are conditionally set to str() values or remain None, and the metadata assignments at lines 2611–2614 guard with if _trace_session_id: — correct None/empty handling.
✅ No Leak into Original Body
The two-step protection works:
body.copy()— creates a new top-level dictdict(body_to_send["metadata"])— creates a new nested dict
The original body parameter passed to chat_completions is never mutated. Verified by tracing all writes: only body_to_send receives mutations.
✅ Error-Path Coverage
Both code paths that create body_to_send converge on the metadata block:
- Success path (line 2565): pre-screening succeeds → metadata check runs
- Exception path (line 2600): pre-screening fails non-HTTPException → new
body.copy()→ same metadata check
4. Global Consistency Checks — ALL PASSING ✅
| Check | Status | Details |
|---|---|---|
| Underscore langfuse headers | ✅ Clean | Zero matches in entire repo |
Duplicate _redis_client globals |
✅ Clean | Exactly 1 instance (line 42) |
Redundant import uuid in generators |
✅ Clean | Both removed |
return None → return in helpers |
✅ Clean | _end_parent_obs, _close_prop_ctx use bare return |
5. Test Results
Unit tests:
pytest router/tests/ -x -q --no-header
92 passed in 0.48s
E2E verification (--dev overlay, dev pod at port 5010):
Results [DEV]: 20/21 passed, 7 skipped, 1 FAILED
The single E2E failure is in the Langfuse session leak test: a prior session_id appeared on a subsequent no-session request's trace. This is likely a pre-existing race condition in OpenTelemetry contextvar propagation, not a regression from this PR. The PR's metadata-based approach is orthogonal — it sets metadata on the downstream LiteLLM request body, while Langfuse session propagation uses the propagate_attributes context manager. All session tracking tests pass.
6. Verdict: APPROVE ✅
Changes are focused, minimal (12 insertions, 13 deletions), and correctly address both Gemini CA findings. 92/92 unit tests pass with zero regression. The metadata deep-copy pattern is robust against fallback retries. Session/user propagation guards handle None/empty correctly.
Reviewed by Hermes Agent
Investigation: E2E Session Leak Test FailureVerdict: FALSE POSITIVE in the test — NOT a code regression and NOT an OTel contextvar race condition. Root CauseThe
The test finds ONE of these as Evidence (from dev Langfuse)Trace [3] is the router child span from step 1. It correctly has session_id. It is NOT from step 2. The leak check rejects it because Why This Looked Like an OTel Race ConditionStep 1 creates Impact of the PRThe PR correctly propagates session_id to Recommended Fix for the TestThe leak check should either:
Code Path Verification
The PR code is correct. The test has a false positive. Approve stands. Investigated by Hermes Agent |
Problem
LiteLLM creates
agent-completionas a separate trace that does not inherit OpenTelemetry context frompropagate_attributes(). Thelitellm-proxytrace correctly getssessionIdanduserId, butagent-completiontraces consistently showsessionId=None.This breaks session-scoped Langfuse dashboards, per-user cost reports, and trace linking — the
agent-completiontrace is where actual token usage, latency, and model metadata live.Confirmed via E2E
Tested against dev:
litellm-proxygetssessionId=e2e-issue303-*, while allagent-completiontraces showsessionId=None.Fix
Propagate
session_idandtrace_user_idvia two channels:Request metadata dict — LiteLLM's Langfuse callback reads
metadata.session_idandmetadata.trace_user_idfor every trace it creates, independent of OTel context. Added alongside the existingmetadata.trace_name = "agent-completion".HTTP headers — Added
langfuse_session_idandlangfuse_trace_user_idheaders alongside the existingX-Langfuse-Trace-Idfor belt-and-suspenders coverage.Both changes apply to streaming and non-streaming paths since they share the same
body_to_sendconstruction block inexecute_proxy().Why not other approaches?
propagate_attributes()— works forlitellm-proxybut cannot cross separate trace trees.existing_trace_id(linking into parent trace): Undesirable — changes trace topology; we already havelitellm-proxyas child observation.Reference
LiteLLM Langfuse Integration docs: https://docs.litellm.ai/docs/observability/langfuse_integration
Closes #303
Summary by CodeRabbit